Skip to content

feat: Add built-in HTTP activity for standalone SDK#697

Open
YunchuWang wants to merge 2 commits intomicrosoft:mainfrom
YunchuWang:feature/builtin-http-activity
Open

feat: Add built-in HTTP activity for standalone SDK#697
YunchuWang wants to merge 2 commits intomicrosoft:mainfrom
YunchuWang:feature/builtin-http-activity

Conversation

@YunchuWang
Copy link
Copy Markdown
Member

Summary

  • Adds new extension package Microsoft.DurableTask.Extensions.Http that implements BuiltIn::HttpActivity, enabling CallHttpAsync to work in standalone mode (e.g., with durabletask-go sidecar) without requiring the Azure Functions host
  • Includes DurableHttpRequest/DurableHttpResponse types wire-compatible with the Azure Functions extension, retry support via HttpRetryOptions, and 202 async polling
  • One-line opt-in via builder.UseHttpActivities() on IDurableTaskWorkerBuilder

Motivation

In standalone scenarios (durabletask-dotnet + durabletask-go sidecar), CallHttpAsync dispatches BuiltIn::HttpActivity as a regular activity, but no worker handles it — resulting in ActivityTaskNotFound. This extension fills that gap.

What's included

Component File(s)
HTTP types DurableHttpRequest.cs, DurableHttpResponse.cs, HttpRetryOptions.cs, TokenSource.cs
JSON converters Converters/HttpMethodConverter.cs, HttpHeadersConverter.cs, TokenSourceConverter.cs
Activity implementation BuiltInHttpActivity.cs — full retry with exponential backoff
Registration DurableTaskBuilderHttpExtensions.csUseHttpActivities()
CallHttpAsync extensions TaskOrchestrationContextHttpExtensions.cs — with 202 polling
Tests BuiltInHttpActivityTests.cs, SerializationTests.cs, RegistrationTests.cs (16 tests)

Usage

// Program.cs
builder.Services.AddDurableTaskWorker(b =>
{
    b.AddTasks(registry => { /* user tasks */ });
    b.UseHttpActivities();  // ← one line
    b.UseGrpc();
});

// In orchestrator
var response = await context.CallHttpAsync(
    new DurableHttpRequest(HttpMethod.Get, new Uri("https://api.example.com/data")));

v1 Limitations

  • TokenSource (managed identity) throws NotSupportedException — pass tokens via headers instead
  • Headers use IDictionary<string, string> (simplified from StringValues)

Test plan

  • 16 unit tests passing (activity execution, retry logic, serialization, registration)
  • CI build verification
  • Integration test with durabletask-go sidecar

Closes #696

🤖 Generated with Claude Code

YunchuWang and others added 2 commits April 2, 2026 16:18
Adds a new extension package (Microsoft.DurableTask.Extensions.Http) that
provides a built-in HTTP activity implementation, enabling CallHttpAsync to
work in standalone mode without the Azure Functions host.

New extension project: src/Extensions/Http/
- DurableHttpRequest/Response — wire-compatible with Azure Functions extension
- BuiltInHttpActivity — executes HTTP requests with retry support
- UseHttpActivities() — one-line registration on IDurableTaskWorkerBuilder
- CallHttpAsync extension methods with 202 async polling
- JSON converters for HttpMethod, headers, and TokenSource

Closes microsoft#696

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@YunchuWang YunchuWang marked this pull request as ready for review April 3, 2026 00:08
Copilot AI review requested due to automatic review settings April 3, 2026 00:08
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new extension package that implements the built-in "BuiltIn::HttpActivity" so TaskOrchestrationContext.CallHttpAsync(...) works in standalone worker scenarios (e.g., durabletask-go sidecar) without relying on the Azure Functions host.

Changes:

  • Introduce Microsoft.DurableTask.Extensions.Http with DurableHttpRequest/DurableHttpResponse, converters, retry options, and the BuiltInHttpActivity implementation.
  • Add worker/orchestrator extension methods: builder.UseHttpActivities() and TaskOrchestrationContext.CallHttpAsync(...) (including 202 async polling).
  • Add a new unit test project covering serialization, registration, and activity execution.

Reviewed changes

Copilot reviewed 18 out of 18 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
src/Extensions/Http/Http.csproj New extension project packaging + dependencies + internals visibility for tests
src/Extensions/Http/DurableTaskBuilderHttpExtensions.cs UseHttpActivities() registration for built-in HTTP activity + named HttpClient
src/Extensions/Http/TaskOrchestrationContextHttpExtensions.cs CallHttpAsync orchestration extensions, including 202 polling behavior
src/Extensions/Http/BuiltInHttpActivity.cs Activity implementation that executes HTTP requests and applies HttpRetryOptions
src/Extensions/Http/DurableHttpRequest.cs Wire-compatible durable HTTP request type (+ converters)
src/Extensions/Http/DurableHttpResponse.cs Wire-compatible durable HTTP response type (+ converters)
src/Extensions/Http/HttpRetryOptions.cs Retry policy model for the built-in HTTP activity
src/Extensions/Http/TokenSource.cs TokenSource models for wire-compat (with standalone limitations)
src/Extensions/Http/Converters/HttpMethodConverter.cs JSON conversion for HttpMethod
src/Extensions/Http/Converters/HttpHeadersConverter.cs JSON conversion for header dictionaries (string or string[])
src/Extensions/Http/Converters/TokenSourceConverter.cs JSON conversion for TokenSource/managed identity model
test/Extensions/Http.Tests/Http.Tests.csproj New test project for the HTTP extension
test/Extensions/Http.Tests/BuiltInHttpActivityTests.cs Unit tests for activity request/response mapping and retries
test/Extensions/Http.Tests/SerializationTests.cs Unit tests for JSON wire round-tripping
test/Extensions/Http.Tests/RegistrationTests.cs Unit tests for DI registration via UseHttpActivities()
Directory.Packages.props Add Microsoft.Extensions.Http package version for shared dependency management
Microsoft.DurableTask.sln Add the new extension + tests to the solution
CHANGELOG.md Document the new HTTP activity extension in Unreleased

Comment on lines +44 to +46
// Handle 202 async polling pattern
while (response.StatusCode == HttpStatusCode.Accepted && request.AsynchronousPatternEnabled)
{
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DurableHttpRequest.Timeout is documented as the total timeout for the HTTP request and any asynchronous polling, but CallHttpAsync never reads/enforces it. As written, the polling loop can run indefinitely if the endpoint keeps returning 202. Consider tracking a deadline (based on context.CurrentUtcDateTime) and stopping/throwing once the timeout is exceeded.

Copilot uses AI. Check for mistakes.
Comment on lines +81 to +82
var pollRequest = new DurableHttpRequest(HttpMethod.Get, new Uri(locationUrl))
{
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Location header used for 202 polling may legally be a relative URI (e.g. /status/123). new Uri(locationUrl) will produce a relative Uri, and the subsequent HTTP activity call will fail unless the HttpClient has a BaseAddress. Consider resolving against the original request URI (e.g., new Uri(request.Uri, locationUrl)) before constructing the poll request.

Suggested change
var pollRequest = new DurableHttpRequest(HttpMethod.Get, new Uri(locationUrl))
{
Uri pollUri;
if (!Uri.TryCreate(locationUrl, UriKind.Absolute, out pollUri))
{
if (request.Uri == null || !request.Uri.IsAbsoluteUri)
{
logger.LogWarning(
"HTTP 202 response returned relative 'Location' header '{LocationUrl}', but the original request URI is missing or relative; unable to poll for status.",
locationUrl);
break;
}
pollUri = new Uri(request.Uri, locationUrl);
}
var pollRequest = new DurableHttpRequest(HttpMethod.Get, pollUri)
{

Copilot uses AI. Check for mistakes.
Comment on lines +58 to +66
if (headers.TryGetValue("Retry-After", out string? retryAfterStr)
&& int.TryParse(retryAfterStr, out int retryAfterSeconds))
{
fireAt = context.CurrentUtcDateTime.AddSeconds(retryAfterSeconds);
}
else
{
fireAt = context.CurrentUtcDateTime.AddMilliseconds(DefaultPollingIntervalMs);
}
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Retry-After can be either delta-seconds or an HTTP-date. The polling logic only supports integer seconds, so valid HTTP-date values will be ignored and fall back to the default interval. Consider also parsing the HTTP-date form to match standard Retry-After semantics.

Copilot uses AI. Check for mistakes.
Comment on lines +140 to +154
if (request.Content != null)
{
httpRequest.Content = new StringContent(request.Content, Encoding.UTF8, "application/json");
}

if (request.Headers != null)
{
foreach (KeyValuePair<string, string> header in request.Headers)
{
// Try request headers first, then content headers
if (!httpRequest.Headers.TryAddWithoutValidation(header.Key, header.Value))
{
httpRequest.Content?.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
}
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BuildHttpRequest always creates StringContent with media type application/json when request.Content is set. This can override user intent (e.g., form data, plain text, custom Content-Type header) and can also lead to duplicated/contradictory Content-Type headers. Consider not hard-coding the media type here and instead honoring an explicit Content-Type header (or leaving it unset).

Copilot uses AI. Check for mistakes.
maxAttempts = 1;
}

TimeSpan delay = retryOptions?.FirstRetryInterval ?? TimeSpan.Zero;
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When HttpRetryOptions is provided but FirstRetryInterval is left at its default (TimeSpan.Zero), retries will occur back-to-back with no delay (and exponential backoff will never increase it from zero). Consider validating FirstRetryInterval when MaxNumberOfAttempts > 1, or applying a small, non-zero default delay to avoid tight retry loops.

Suggested change
TimeSpan delay = retryOptions?.FirstRetryInterval ?? TimeSpan.Zero;
TimeSpan delay = retryOptions?.FirstRetryInterval ?? TimeSpan.Zero;
if (maxAttempts > 1 && delay <= TimeSpan.Zero)
{
delay = TimeSpan.FromSeconds(1);
}

Copilot uses AI. Check for mistakes.
Comment on lines +17 to +26
public override IDictionary<string, string> Read(
ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);

if (reader.TokenType != JsonTokenType.StartObject)
{
return headers;
}

Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HttpHeadersConverter.Read does not handle JsonTokenType.Null. If JSON contains "headers": null, the converter returns an empty dictionary, which changes the meaning for nullable Headers properties (callers cannot distinguish null vs empty) and may break wire-compat expectations. Consider supporting null explicitly (e.g., use a nullable converter JsonConverter<IDictionary<string,string>?> and return null for JsonTokenType.Null).

Copilot uses AI. Check for mistakes.
Comment on lines +10 to +18
/// JSON converter for <see cref="TokenSource"/> — handles serialization only.
/// Deserialization is not supported since token acquisition is not available in standalone mode.
/// </summary>
internal sealed class TokenSourceConverter : JsonConverter<TokenSource>
{
/// <inheritdoc/>
public override TokenSource? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
// Skip the token source object during deserialization — token acquisition is not supported.
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The XML doc comment says this converter “handles serialization only” and that “deserialization is not supported”, but Read(...) is implemented and returns a ManagedIdentityTokenSource. Please update the documentation to reflect the actual behavior (or adjust the implementation to match the documented intent).

Suggested change
/// JSON converter for <see cref="TokenSource"/> — handles serialization only.
/// Deserialization is not supported since token acquisition is not available in standalone mode.
/// </summary>
internal sealed class TokenSourceConverter : JsonConverter<TokenSource>
{
/// <inheritdoc/>
public override TokenSource? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
// Skip the token source object during deserialization — token acquisition is not supported.
/// JSON converter for <see cref="TokenSource"/>.
/// Supports serialization of <see cref="TokenSource"/> instances and deserialization of managed identity
/// token source payloads into <see cref="ManagedIdentityTokenSource"/>.
/// </summary>
internal sealed class TokenSourceConverter : JsonConverter<TokenSource>
{
/// <inheritdoc/>
public override TokenSource? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
// Handle null values directly. Unrecognized token source payloads are deserialized as null.

Copilot uses AI. Check for mistakes.
Comment on lines +52 to +61
registry.AddActivity(
new TaskName(HttpTaskActivityName),
sp =>
{
IHttpClientFactory httpClientFactory = sp.GetRequiredService<IHttpClientFactory>();
HttpClient client = httpClientFactory.CreateClient("DurableHttp");
ILogger logger = sp.GetRequiredService<ILoggerFactory>()
.CreateLogger("Microsoft.DurableTask.Http.BuiltInHttpActivity");
return new BuiltInHttpActivity(client, logger);
});
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UseHttpActivities() unconditionally calls registry.AddActivity(...). Since DurableTaskRegistry.AddActivity throws if the name is already registered, calling UseHttpActivities() in an app that has already registered BuiltIn::HttpActivity (or calling UseHttpActivities() twice) will throw. Consider making registration idempotent or at least surfacing a clearer exception/message about the duplicate registration scenario.

Suggested change
registry.AddActivity(
new TaskName(HttpTaskActivityName),
sp =>
{
IHttpClientFactory httpClientFactory = sp.GetRequiredService<IHttpClientFactory>();
HttpClient client = httpClientFactory.CreateClient("DurableHttp");
ILogger logger = sp.GetRequiredService<ILoggerFactory>()
.CreateLogger("Microsoft.DurableTask.Http.BuiltInHttpActivity");
return new BuiltInHttpActivity(client, logger);
});
try
{
registry.AddActivity(
new TaskName(HttpTaskActivityName),
sp =>
{
IHttpClientFactory httpClientFactory = sp.GetRequiredService<IHttpClientFactory>();
HttpClient client = httpClientFactory.CreateClient("DurableHttp");
ILogger logger = sp.GetRequiredService<ILoggerFactory>()
.CreateLogger("Microsoft.DurableTask.Http.BuiltInHttpActivity");
return new BuiltInHttpActivity(client, logger);
});
}
catch (System.InvalidOperationException e)
{
throw new System.InvalidOperationException(
$"The built-in HTTP activity '{HttpTaskActivityName}' is already registered. " +
$"This can happen if UseHttpActivities() is called more than once or if the same activity name was registered manually before calling UseHttpActivities().",
e);
}

Copilot uses AI. Check for mistakes.
Comment on lines +31 to +34
/// <see cref="System.Net.Http.HttpClient"/> to execute HTTP requests. If an <c>IHttpClientFactory</c>
/// is registered in the service collection, a named client <c>"DurableHttp"</c> is used.
/// </para>
/// <para>
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The remarks state “If an IHttpClientFactory is registered in the service collection, a named client ... is used”, but UseHttpActivities() always registers IHttpClientFactory via AddHttpClient(...) and then requires it via GetRequiredService<IHttpClientFactory>(). Consider updating the remarks to reflect that this extension always uses the named client and will fail if the DI registration is removed/overridden.

Suggested change
/// <see cref="System.Net.Http.HttpClient"/> to execute HTTP requests. If an <c>IHttpClientFactory</c>
/// is registered in the service collection, a named client <c>"DurableHttp"</c> is used.
/// </para>
/// <para>
/// <see cref="System.Net.Http.HttpClient"/> to execute HTTP requests. This extension also registers
/// and uses a named <see cref="IHttpClientFactory"/> client named <c>"DurableHttp"</c>.
/// </para>
/// <para>
/// The built-in activity resolves <see cref="IHttpClientFactory"/> from the service provider and
/// creates the <c>"DurableHttp"</c> client at runtime. Removing or overriding that DI registration
/// in a way that makes <see cref="IHttpClientFactory"/> unavailable will cause this extension to fail.
/// </para>
/// <para>

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support CallHttpAsync in Standalone SDK

2 participants